Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit d2456c8ac313aeddedc6b3df9abb2c0458641771


Parents : 1a6f7f3
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-07-09T03:46:25-05:00

fix: RRC message stacking, NomadNet favourites/sections, and desktop close prompt

Changes

33 files changed, 1206 insertions(+), 75 deletions(-)

M electron/main.js +117 -12

Diff

diff --git a/electron/closeBehavior.js b/electron/closeBehavior.js
new file mode 100644
index 00000000..a5ac1839
--- /dev/null
+++ b/electron/closeBehavior.js
@@ -0,0 +1,121 @@
+const fs = require("fs");
+const path = require("node:path");
+
+const CLOSE_BEHAVIORS = new Set(["ask", "quit", "background"]);
+
+function defaultCloseSettings() {
+ return {
+ closeBehavior: "ask",
+ trayEnabled: true,
+ };
+}
+
+function closeSettingsPath(storageDir) {
+ return path.join(storageDir, "desktop-close-settings.json");
+}
+
+function normalizeCloseSettings(raw) {
+ const defaults = defaultCloseSettings();
+ if (!raw || typeof raw !== "object") {
+ return defaults;
+ }
+ const closeBehavior = CLOSE_BEHAVIORS.has(raw.closeBehavior) ? raw.closeBehavior : defaults.closeBehavior;
+ const trayEnabled = typeof raw.trayEnabled === "boolean" ? raw.trayEnabled : defaults.trayEnabled;
+ return { closeBehavior, trayEnabled };
+}
+
+function loadCloseSettings(storageDir) {
+ try {
+ const filePath = closeSettingsPath(storageDir);
+ if (!fs.existsSync(filePath)) {
+ return defaultCloseSettings();
+ }
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
+ return normalizeCloseSettings(parsed);
+ } catch {
+ return defaultCloseSettings();
+ }
+}
+
+function saveCloseSettings(storageDir, partial) {
+ const current = loadCloseSettings(storageDir);
+ const next = normalizeCloseSettings({
+ ...current,
+ ...(partial && typeof partial === "object" ? partial : {}),
+ });
+ try {
+ fs.mkdirSync(storageDir, { recursive: true });
+ fs.writeFileSync(closeSettingsPath(storageDir), JSON.stringify(next, null, 2), "utf8");
+ } catch {
+ // ignore persistence failures; in-memory choice still applies for this session
+ }
+ return next;
+}
+
+/**
+ * Resolve what to do on window close.
+ * @returns {"ask"|"quit"|"background"|"minimize"}
+ */
+function resolveCloseAction(settings) {
+ const normalized = normalizeCloseSettings(settings);
+ if (normalized.closeBehavior === "ask") {
+ return "ask";
+ }
+ if (normalized.closeBehavior === "quit") {
+ return "quit";
+ }
+ if (!normalized.trayEnabled) {
+ return "minimize";
+ }
+ return "background";
+}
+
+/**
+ * Map a chosen close action + remember checkbox into persisted settings.
+ * Non-quit choices are stored as "background" and resolved to minimize when tray is off.
+ * @param {"quit"|"background"|"minimize"} action
+ * @param {boolean} remember
+ * @returns {{ closeBehavior: "quit"|"background" }|null}
+ */
+function rememberedCloseSettings(action, remember) {
+ if (!remember) {
+ return null;
+ }
+ if (action === "quit") {
+ return { closeBehavior: "quit" };
+ }
+ if (action === "background" || action === "minimize") {
+ return { closeBehavior: "background" };
+ }
+ return null;
+}
+
+/**
+ * Simple re-entrancy guard for async close handling.
+ */
+function createCloseRequestGuard() {
+ let inFlight = false;
+ return {
+ tryEnter() {
+ if (inFlight) {
+ return false;
+ }
+ inFlight = true;
+ return true;
+ },
+ leave() {
+ inFlight = false;
+ },
+ };
+}
+
+module.exports = {
+ CLOSE_BEHAVIORS,
+ defaultCloseSettings,
+ loadCloseSettings,
+ saveCloseSettings,
+ resolveCloseAction,
+ normalizeCloseSettings,
+ rememberedCloseSettings,
+ createCloseRequestGuard,
+};

diff --git a/electron/main.js b/electron/main.js
index 4066dae1..0c759012 100644
--- a/electron/main.js
+++ b/electron/main.js
@@ -27,9 +27,18 @@ const {
} = require("./mainHelpers");
const { isAllowedShellPath } = require("./shellPathGuard");
const { normalizeExternalUrlForOpen } = require("./safeExternalUrl");
+const {
+ loadCloseSettings,
+ saveCloseSettings,
+ resolveCloseAction,
+ rememberedCloseSettings,
+ createCloseRequestGuard,
+} = require("./closeBehavior");
// remember main window
var mainWindow = null;
+var closeSettings = null;
+var closeRequestGuard = createCloseRequestGuard();
function getDialogParentWindow() {
const focused = BrowserWindow.getFocusedWindow();
@@ -339,6 +348,14 @@ ipcMain.handle("shutdown", () => {
quit();
});
+ipcMain.handle("get-close-settings", () => {
+ return getCloseSettings();
+});
+
+ipcMain.handle("set-close-settings", (_event, partial) => {
+ return updateCloseSettings(partial || {});
+});
+
ipcMain.handle("get-memory-usage", async () => {
return process.getProcessMemoryInfo();
});
@@ -648,7 +665,30 @@ function getBackendManager() {
return backendManager;
}
+function getCloseSettings() {
+ if (!closeSettings) {
+ closeSettings = loadCloseSettings(getDefaultStorageDir());
+ }
+ return closeSettings;
+}
+
+function updateCloseSettings(partial) {
+ closeSettings = saveCloseSettings(getDefaultStorageDir(), partial);
+ syncTrayWithSettings();
+ return closeSettings;
+}
+
+function destroyTray() {
+ if (tray && !tray.isDestroyed()) {
+ tray.destroy();
+ }
+ tray = null;
+}
+
function createTray() {
+ if (tray && !tray.isDestroyed()) {
+ return;
+ }
tray = new Tray(getAppIconPath());
const contextMenu = Menu.buildFromTemplate([
{
@@ -682,6 +722,78 @@ function createTray() {
});
}
+function syncTrayWithSettings() {
+ const settings = getCloseSettings();
+ if (settings.trayEnabled) {
+ createTray();
+ } else {
+ destroyTray();
+ }
+}
+
+async function promptCloseAction() {
+ const settings = getCloseSettings();
+ const backgroundLabel = settings.trayEnabled ? "Keep running in background" : "Minimize to taskbar";
+ const backgroundDetail = settings.trayEnabled
+ ? "Hide the window and keep MeshChatX in the system tray."
+ : "Minimize MeshChatX to the taskbar.";
+ const result = await dialog.showMessageBox(getDialogParentWindow() || undefined, {
+ type: "question",
+ title: "Close MeshChatX?",
+ message: "Close MeshChatX?",
+ detail: `Choose whether to quit the application or keep it running.\n\n${backgroundDetail}`,
+ buttons: ["Cancel", "Quit application", backgroundLabel],
+ defaultId: 2,
+ cancelId: 0,
+ checkboxLabel: "Remember my choice",
+ checkboxChecked: false,
+ });
+ if (result.response === 0) {
+ return null;
+ }
+ const action = result.response === 1 ? "quit" : settings.trayEnabled ? "background" : "minimize";
+ const remembered = rememberedCloseSettings(action, result.checkboxChecked);
+ if (remembered) {
+ updateCloseSettings(remembered);
+ }
+ return action;
+}
+
+async function handleWindowCloseRequest(event) {
+ if (isQuiting) {
+ return;
+ }
+ event.preventDefault();
+ if (!closeRequestGuard.tryEnter()) {
+ return;
+ }
+ try {
+ const settings = getCloseSettings();
+ let action = resolveCloseAction(settings);
+ if (action === "ask") {
+ action = await promptCloseAction();
+ if (!action) {
+ return;
+ }
+ }
+ if (action === "quit") {
+ isQuiting = true;
+ quit();
+ return;
+ }
+ if (!mainWindow || mainWindow.isDestroyed()) {
+ return;
+ }
+ if (action === "minimize") {
+ mainWindow.minimize();
+ return;
+ }
+ mainWindow.hide();
+ } finally {
+ closeRequestGuard.leave();
+ }
+}
+
app.whenReady().then(async () => {
app.on("browser-window-created", (event, browserWindow) => {
attachDefaultContextMenu(browserWindow);
@@ -721,8 +833,8 @@ app.whenReady().then(async () => {
const isHardwareAccelerationEnabled = app.isHardwareAccelerationEnabled();
log(`Hardware Acceleration Enabled: ${isHardwareAccelerationEnabled}`);
- // Create system tray
- createTray();
+ // Create system tray when enabled in desktop close settings
+ syncTrayWithSettings();
// get arguments passed to application, and remove the provided application path
const userProvidedArguments = getUserProvidedArguments(process.argv);
@@ -779,13 +891,9 @@ app.whenReady().then(async () => {
}
);
- // minimize to tray behavior
+ // quit / minimize / hide-to-tray based on remembered close settings
mainWindow.on("close", (event) => {
- if (!isQuiting) {
- event.preventDefault();
- mainWindow.hide();
- return false;
- }
+ void handleWindowCloseRequest(event);
});
// navigate to loading page
@@ -938,10 +1046,7 @@ app.on("before-quit", () => {
isQuiting = true;
}
// Ensure tray is destroyed to prevent it from keeping the app alive
- if (tray && !tray.isDestroyed()) {
- tray.destroy();
- tray = null;
- }
+ destroyTray();
});
// quit electron if all windows are closed

diff --git a/electron/preload.js b/electron/preload.js
index 09bcee15..7e90f896 100644
--- a/electron/preload.js
+++ b/electron/preload.js
@@ -70,6 +70,14 @@ contextBridge.exposeInMainWorld("electron", {
return await ipcRenderer.invoke("shutdown");
},
+ getCloseSettings: async function () {
+ return await ipcRenderer.invoke("get-close-settings");
+ },
+
+ setCloseSettings: async function (partial) {
+ return await ipcRenderer.invoke("set-close-settings", partial);
+ },
+
// allow getting memory usage in electron browser window
getMemoryUsage: async function () {
return await ipcRenderer.invoke("get-memory-usage");

diff --git a/meshchatx/src/backend/database/announces.py b/meshchatx/src/backend/database/announces.py
index 289cc861..066b25ac 100644
--- a/meshchatx/src/backend/database/announces.py
+++ b/meshchatx/src/backend/database/announces.py
@@ -187,14 +187,32 @@ class AnnounceDAO:
# Favourites
def upsert_favourite(self, destination_hash, display_name, aspect):
+ from meshchatx.src.backend.favourite_display_names import (
+ is_unknown_favourite_display_name,
+ )
+
now = datetime.now(UTC)
+ preserve_unknown = is_unknown_favourite_display_name(display_name)
self.provider.execute(
"""
INSERT INTO favourite_destinations (destination_hash, display_name, aspect, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
- ON CONFLICT(destination_hash) DO UPDATE SET display_name = EXCLUDED.display_name, aspect = EXCLUDED.aspect, updated_at = EXCLUDED.updated_at
+ ON CONFLICT(destination_hash) DO UPDATE SET
+ display_name = CASE
+ WHEN ? THEN favourite_destinations.display_name
+ ELSE EXCLUDED.display_name
+ END,
+ aspect = EXCLUDED.aspect,
+ updated_at = EXCLUDED.updated_at
""",
- (destination_hash, display_name, aspect, now, now),
+ (
+ destination_hash,
+ display_name,
+ aspect,
+ now,
+ now,
+ 1 if preserve_unknown else 0,
+ ),
)
def get_favourite_by_destination_hash(self, destination_hash):

diff --git a/meshchatx/src/backend/favourite_display_names.py b/meshchatx/src/backend/favourite_display_names.py
new file mode 100644
index 00000000..5c77b3c3
--- /dev/null
+++ b/meshchatx/src/backend/favourite_display_names.py
@@ -0,0 +1,25 @@
+"""Shared favourite display-name sentinels that must not clobber stored names."""
+
+# Keep in sync with meshchatx/src/frontend/js/nomadUnknownNodeName.js
+UNKNOWN_FAVOURITE_NAMES = frozenset(
+ {
+ "",
+ "Unknown Node",
+ "Anonymous Node",
+ "Unbekannter Knoten",
+ "Nodo desconocido",
+ "Tuntematon solmu",
+ "Noeud inconnu",
+ "Nodo Sconosciuto",
+ "Onbekende knoop",
+ "Неизвестный узел",
+ "未知节点",
+ }
+)
+
+
+def is_unknown_favourite_display_name(name) -> bool:
+ """Return True when ``name`` is empty or a known unknown-node placeholder."""
+ if not isinstance(name, str):
+ return True
+ return name.strip() in UNKNOWN_FAVOURITE_NAMES

diff --git a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
index 8edd786e..68846141 100644
--- a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
+++ b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
@@ -621,6 +621,7 @@ import {
} from "../../js/MicronWasmLoader";
import { VTooltip } from "vuetify/components/VTooltip";
import { loadFeatureSidebarCollapsed, saveFeatureSidebarCollapsed } from "../../js/browserLayoutStore";
+import { isUnknownNodeDisplayName, resolveFavouriteUpsertDisplayName } from "../../js/nomadUnknownNodeName.js";
export default {
name: "NomadNetworkPage",
@@ -1071,15 +1072,7 @@ export default {
(async () => {
await this.getNomadnetworkNodeAnnounce(bootstrapHash);
- if (this.nodes[bootstrapHash]) {
- this.selectedNode = this.nodes[bootstrapHash];
- } else {
- this.selectedNode = {
- destination_hash: bootstrapHash,
- display_name: "Unknown Node",
- aspect: "nomadnetwork.node",
- };
- }
+ this.selectedNode = this.resolveNodeForHash(bootstrapHash);
this.getNodePath(bootstrapHash);
@@ -1114,11 +1107,7 @@ export default {
}
try {
await this.getNomadnetworkNodeAnnounce(hash);
- this.selectedNode = this.nodes[hash] || {
- destination_hash: hash,
- display_name: this.$t("nomadnet.unknown_node"),
- aspect: "nomadnetwork.node",
- };
+ this.selectedNode = this.resolveNodeForHash(hash);
const path = typeof pagePath === "string" && pagePath.length > 0 ? pagePath : this.defaultNodePagePath;
await this.loadNodePage(hash, path, null, false, true);
} catch (e) {
@@ -1584,6 +1573,48 @@ export default {
console.log(e);
}
},
+ isUnknownNodeName(name) {
+ return isUnknownNodeDisplayName(name, this.$t("nomadnet.unknown_node"));
+ },
+ resolveNodeForHash(destinationHash) {
+ const hash = (destinationHash || "").trim();
+ if (!hash) {
+ return null;
+ }
+ const cached = this.nodes[hash];
+ const favourite = this.favourites.find((f) => f.destination_hash === hash);
+ const favouriteName = favourite?.custom_display_name || favourite?.display_name || "";
+ if (cached) {
+ const cachedName = cached.custom_display_name || cached.display_name || "";
+ if (this.isUnknownNodeName(cachedName) && favouriteName && !this.isUnknownNodeName(favouriteName)) {
+ return {
+ ...cached,
+ display_name: favouriteName,
+ custom_display_name: favourite?.custom_display_name || favouriteName,
+ };
+ }
+ return cached;
+ }
+ if (favouriteName && !this.isUnknownNodeName(favouriteName)) {
+ return {
+ ...favourite,
+ display_name: favouriteName,
+ aspect: favourite.aspect || "nomadnetwork.node",
+ };
+ }
+ const selectedHash = this.selectedNode?.destination_hash;
+ if (selectedHash && Object.is(selectedHash, hash)) {
+ const existingName = this.selectedNode.custom_display_name || this.selectedNode.display_name;
+ if (existingName && !this.isUnknownNodeName(existingName)) {
+ return this.selectedNode;
+ }
+ }
+ return {
+ destination_hash: hash,
+ display_name: this.$t("nomadnet.unknown_node"),
+ aspect: "nomadnetwork.node",
+ };
+ },
isFavourite(destinationHash) {
return (
this.favourites.find((favourite) => {
@@ -1593,9 +1624,13 @@ export default {
},
async addFavourite(node) {
try {
+ const existing = this.favourites.find(
+ (favourite) => favourite.destination_hash === node.destination_hash
+ );
+ const displayName = resolveFavouriteUpsertDisplayName(node, existing, this.$t("nomadnet.unknown_node"));
await window.api.post("/api/v1/favourites/add", {
destination_hash: node.destination_hash,
- display_name: node.display_name,
+ display_name: displayName,
aspect: "nomadnetwork.node",
});
await this.getFavourites();
@@ -1643,9 +1678,10 @@ export default {
continue;
}
try {
+ const displayName = resolveFavouriteUpsertDisplayName(node, null, this.$t("nomadnet.unknown_node"));
await window.api.post("/api/v1/favourites/add", {
destination_hash: node.destination_hash,
- display_name: node.display_name,
+ display_name: displayName,
aspect: "nomadnetwork.node",
});
added += 1;
@@ -2472,10 +2508,7 @@ export default {
}
// update selected node, so relative urls work correctly when returned by the new node
- this.selectedNode = this.nodes[destinationHash] || {
- display_name: this.$t("nomadnet.unknown_node"),
- destination_hash: destinationHash,
- };
+ this.selectedNode = this.resolveNodeForHash(destinationHash);
// navigate to node page
this.loadNodePage(destinationHash, parsedUrl.path, fieldData, addToHistory, useCache, navOptions);
@@ -2492,18 +2525,21 @@ export default {
return Utils.formatBytesPerSecond(bytesPerSecond);
},
onNodeClick: function (node) {
- if (this.shouldOpenInNewTab(node.destination_hash, {})) {
- this.emitOpenNode(
- node.destination_hash,
- this.defaultNodePagePath,
- node.custom_display_name || node.display_name || null,
- { activate: true }
- );
+ const hash = node?.destination_hash;
+ const resolved = hash ? this.resolveNodeForHash(hash) : node;
+ const title =
+ resolved?.custom_display_name ||
+ resolved?.display_name ||
+ node?.custom_display_name ||
+ node?.display_name ||
+ null;
+ if (this.shouldOpenInNewTab(hash, {})) {
+ this.emitOpenNode(hash, this.defaultNodePagePath, title, { activate: true });
return;
}
- this.selectedNode = node;
- this.loadNodePage(node.destination_hash, this.defaultNodePagePath);
+ this.selectedNode = resolved || node;
+ this.loadNodePage(hash, this.defaultNodePagePath);
},
async onRenameFavourite(favourite) {
// ask user for new display name

diff --git a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue
index 667e4e44..27c9f127 100644
--- a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue
+++ b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue
@@ -57,7 +57,7 @@
? 'ring-2 ring-blue-500 ring-offset-1 ring-offset-white dark:ring-offset-zinc-950'
: 'hover:bg-white/10'
"
- :title="fav.display_name"
+ :title="favouriteDisplayName(fav)"
@click="onFavouriteClick(fav)"
>
<MaterialDesignIcon icon-name="server-network" class="size-6 text-gray-600 dark:text-gray-300" />
@@ -348,9 +348,9 @@
<div class="min-w-0 flex-1">
<div
class="text-sm font-semibold text-gray-900 dark:text-white truncate"
- :title="favourite.display_name"
+ :title="favouriteDisplayName(favourite)"
>
- {{ favourite.display_name }}
+ {{ favouriteDisplayName(favourite) }}
</div>
<div
class="text-xs text-gray-500 dark:text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 cursor-pointer inline-flex items-center"
@@ -715,6 +715,7 @@ import GlobalState from "../../js/GlobalState";
import GlobalEmitter from "../../js/GlobalEmitter";
import ToastUtils from "../../js/ToastUtils";
import DownloadUtils from "../../js/DownloadUtils";
+import { isUnknownNodeDisplayName } from "../../js/nomadUnknownNodeName.js";
export default {
name: "NomadNetworkSidebar",
@@ -1192,6 +1193,9 @@ export default {
}
},
ensureFavouriteLayout() {
+ if (!Array.isArray(this.favourites) || this.favourites.length === 0) {
+ return;
+ }
if (this.sections.length === 0) {
this.resetDefaultSections();
}
@@ -1214,13 +1218,12 @@ export default {
sanitizedSections.unshift(defaultSection);
sectionIds.add(defaultSection.id);
}
- this.sections = sanitizedSections;
const existingOrder = Array.isArray(this.sectionOrder) ? this.sectionOrder : [];
const filteredOrder = existingOrder.filter((id) => sectionIds.has(id));
const remaining = sanitizedSections
.map((section) => section.id)
.filter((id) => !filteredOrder.includes(id));
- this.sectionOrder = [...filteredOrder, ...remaining];
+ const nextSectionOrder = [...filteredOrder, ...remaining];
const nextFavouritesBySection = {};
sanitizedSections.forEach((section) => {
@@ -1234,8 +1237,17 @@ export default {
assigned.add(hash);
}
});
+
+ const sectionsChanged = JSON.stringify(this.sections) !== JSON.stringify(sanitizedSections);
+ const orderChanged = JSON.stringify(this.sectionOrder) !== JSON.stringify(nextSectionOrder);
+ const favouritesChanged =
+ JSON.stringify(this.favouritesBySection) !== JSON.stringify(nextFavouritesBySection);
+ this.sections = sanitizedSections;
+ this.sectionOrder = nextSectionOrder;
this.favouritesBySection = nextFavouritesBySection;
- this.persistFavouriteLayout();
+ if (sectionsChanged || orderChanged || favouritesChanged) {
+ this.persistFavouriteLayout();
+ }
},
isBlocked(identityHash) {
return this.blockedDestinations.some((b) => b.destination_hash === identityHash);
@@ -1243,6 +1255,22 @@ export default {
isFavourite(destinationHash) {
return this.favourites.some((f) => f.destination_hash === destinationHash);
},
+ favouriteDisplayName(favourite) {
+ if (!favourite) {
+ return "";
+ }
+ const hash = favourite.destination_hash;
+ const cached = hash ? this.nodes?.[hash] : null;
+ const cachedName = cached?.custom_display_name || cached?.display_name || "";
+ if (cachedName && !isUnknownNodeDisplayName(cachedName, this.$t("nomadnet.unknown_node"))) {
+ return cachedName;
+ }
+ const favouriteName = favourite.custom_display_name || favourite.display_name || "";
+ if (favouriteName && !isUnknownNodeDisplayName(favouriteName, this.$t("nomadnet.unknown_node"))) {
+ return favouriteName;
+ }
+ return favouriteName || this.$t("nomadnet.unknown_node");
+ },
addFavouriteFromContext() {
const node = this.announceContextMenu.node;
if (!node) {

diff --git a/meshchatx/src/frontend/components/relay/RelayChatPage.vue b/meshchatx/src/frontend/components/relay/RelayChatPage.vue
index 51f13a1f..300f5817 100644
--- a/meshchatx/src/frontend/components/relay/RelayChatPage.vue
+++ b/meshchatx/src/frontend/components/relay/RelayChatPage.vue
@@ -412,7 +412,10 @@
{{ $t("relay_chat.load_previous") }}
</button>
<template v-if="!useVirtualMessageList">
- <template v-for="entry in messageTimeline" :key="timelineEntryKey(entry)">
+ <template
+ v-for="(entry, entryIndex) in messageTimeline"
+ :key="timelineEntryKey(entry, entryIndex)"
+ >
<RelayMessageEntry :entry="entry" :page="relayChatPageSelf" />
</template>
</template>
@@ -1102,7 +1105,12 @@ import Utils from "../../js/Utils";
import { DEFAULT_RRC_HUB_ICON, normalizeMdiIconName } from "../../js/mdiIconNames.js";
import { countRelayMentions } from "../../js/relayMentionCount.js";
import { filterRelayMembers, filterRelayMessages } from "../../js/relayMessageSearch.js";
-import { buildRelayMessageTimeline, relayMessageKey } from "../../js/relayMessageTimeline.js";
+import {
+ buildRelayMessageTimeline,
+ mergeRelayMessages,
+ relayMessageAlreadyPresent,
+ relayMessageKey,
+} from "../../js/relayMessageTimeline.js";
import { MIN_VIRTUAL_RELAY_ENTRIES } from "./relayMessageListVirtual.js";
import { loadRelayLayout, saveRelayLayout } from "../../js/relayLayoutStore.js";
import { loadFeatureSidebarCollapsed, saveFeatureSidebarCollapsed } from "../../js/browserLayoutStore.js";
@@ -1822,11 +1830,12 @@ export default {
messageKey(msg) {
return relayMessageKey(msg);
},
- timelineEntryKey(entry) {
+ timelineEntryKey(entry, index = 0) {
if (entry.type === "dateDivider") {
- return `date-${entry.dayKey}`;
+ return `date-${entry.dayKey}-${index}`;
}
- return this.messageKey(entry.msg);
+ const msgKey = this.messageKey(entry.msg);
+ return msgKey ? `${msgKey}-${index}` : `idx-${index}`;
},
formatDateDividerLabel(dayKey) {
if (!dayKey || typeof dayKey !== "string") {
@@ -1946,6 +1955,9 @@ export default {
this.selectedRoom = room;
this.expandedHubs[hubHash] = true;
this.hasMorePrevious = false;
+ // Clear before fetch so only websocket arrivals during the request are merged back.
+ this.messages = [];
+ this.members = [];
const seq = ++this.roomSelectSequence;
try {
const response = await window.api.get(
@@ -1955,7 +1967,8 @@ export default {
if (seq !== this.roomSelectSequence) {
return;
}
- this.messages = response.data?.messages || [];
+ const loaded = response.data?.messages || [];
+ this.messages = mergeRelayMessages(loaded, this.messages);
this.members = response.data?.members || [];
this.hasMorePrevious = Boolean(response.data?.has_more);
this.scrollToBottom();
@@ -1967,8 +1980,6 @@ export default {
if (seq !== this.roomSelectSequence) {
return;
}
- this.messages = [];
- this.members = [];
this.hasMorePrevious = false;
}
},
@@ -1999,10 +2010,23 @@ export default {
if (older.length === 0) {
return;
}
+ const existingSeqs = new Set(this.messages.filter((m) => m && m.seq != null).map((m) => m.seq));
+ const uniqueOlder = older.filter((m) => {
+ if (!m) {
+ return false;
+ }
+ if (m.seq != null && existingSeqs.has(m.seq)) {
+ return false;
+ }
+ return !relayMessageAlreadyPresent(this.messages, m);
+ });
+ if (uniqueOlder.length === 0) {
+ return;
+ }
const scrollEl = this.useVirtualMessageList ? null : this.$refs.messageList;
const prevScrollHeight = scrollEl ? scrollEl.scrollHeight : 0;
const prevScrollTop = scrollEl ? scrollEl.scrollTop : 0;
- this.messages = [...older, ...this.messages];
+ this.messages = [...uniqueOlder, ...this.messages];
if (scrollEl) {
nextTick(() => {
const delta = scrollEl.scrollHeight - prevScrollHeight;
@@ -2475,8 +2499,10 @@ export default {
this.fetchHubs();
} else if (json.type === "rrc.message") {
if (json.hub_hash === this.selectedHubHash && json.room === this.selectedRoom && json.message) {
- this.messages.push(json.message);
- this.scrollToBottom();
+ if (!relayMessageAlreadyPresent(this.messages, json.message)) {
+ this.messages.push(json.message);
+ this.scrollToBottom();
+ }
if (json.message.kind === "system" || json.message.kind === "notice") {
this.refreshMembers();
}

diff --git a/meshchatx/src/frontend/components/relay/RelayMessageListVirtual.vue b/meshchatx/src/frontend/components/relay/RelayMessageListVirtual.vue
index 963e4e47..ca7aa230 100644
--- a/meshchatx/src/frontend/components/relay/RelayMessageListVirtual.vue
+++ b/meshchatx/src/frontend/components/relay/RelayMessageListVirtual.vue
@@ -50,12 +50,13 @@ const totalSize = computed(() => virtualizer.value.getTotalSize());
function entryKey(entry, index) {
if (!entry) {
- return index;
+ return `idx-${index}`;
}
if (entry.type === "dateDivider") {
- return `date-${entry.dayKey}`;
+ return `date-${entry.dayKey}-${index}`;
}
- return props.page.messageKey(entry.msg);
+ const msgKey = props.page.messageKey(entry.msg);
+ return msgKey ? `${msgKey}-${index}` : `idx-${index}`;
}
function measureElement(el) {

diff --git a/meshchatx/src/frontend/components/relay/relayMessageListVirtual.js b/meshchatx/src/frontend/components/relay/relayMessageListVirtual.js
index 7fe4c05d..cf71f7ae 100644
--- a/meshchatx/src/frontend/components/relay/relayMessageListVirtual.js
+++ b/meshchatx/src/frontend/components/relay/relayMessageListVirtual.js
@@ -13,9 +13,10 @@ export function estimateRelayEntryHeight(entry) {
return 44;
}
const text = typeof entry.msg?.text === "string" ? entry.msg.text : "";
- let height = 28;
+ let height = 32;
if (text) {
- height += Math.ceil(text.length / 80) * 18;
+ const lines = text.split("\n").reduce((sum, line) => sum + Math.max(1, Math.ceil(line.length / 72)), 0);
+ height += Math.max(0, lines - 1) * 20;
}
return height;
}

diff --git a/meshchatx/src/frontend/components/settings/SettingsPage.vue b/meshchatx/src/frontend/components/settings/SettingsPage.vue
index 620ab1eb..1e8b49dc 100644
--- a/meshchatx/src/frontend/components/settings/SettingsPage.vue
+++ b/meshchatx/src/frontend/components/settings/SettingsPage.vue
@@ -841,6 +841,45 @@
<span class="setting-toggle__hint">{{ $t("app.requires_restart") }}</span>
</span>
</label>
+
+ <label class="setting-toggle">
+ <Toggle
+ id="desktop-tray-enabled"
+ v-model="desktopCloseSettings.trayEnabled"
+ @update:model-value="onDesktopTrayEnabledChange"
+ />
+ <span class="setting-toggle__label">
+ <span class="setting-toggle__title">{{ $t("app.desktop_tray_enabled") }}</span>
+ <span class="setting-toggle__description">{{
+ $t("app.desktop_tray_enabled_description")
+ }}</span>
+ </span>
+ </label>
+
+ <label class="flex flex-col gap-2">
+ <span class="text-sm font-medium text-sem-fg">{{
+ $t("app.desktop_close_behavior")
+ }}</span>
+ <span class="text-xs text-sem-fg-muted">{{
+ $t("app.desktop_close_behavior_description")
+ }}</span>
+ <select
+ id="desktop-close-behavior"
+ v-model="desktopCloseSettings.closeBehavior"
+ class="input-field"
+ @change="onDesktopCloseBehaviorChange"
+ >
+ <option value="ask">{{ $t("app.desktop_close_behavior_ask") }}</option>
+ <option value="quit">{{ $t("app.desktop_close_behavior_quit") }}</option>
+ <option value="background">
+ {{
+ desktopCloseSettings.trayEnabled
+ ? $t("app.desktop_close_behavior_background")
+ : $t("app.desktop_close_behavior_background_no_tray")
+ }}
+ </option>
+ </select>
+ </label>
</div>
</section>
@@ -3019,6 +3058,10 @@ export default {
visualiserShowDiscoveredInterfaces: false,
selfTestRunning: false,
selfTestResults: null,
+ desktopCloseSettings: {
+ closeBehavior: "ask",
+ trayEnabled: true,
+ },
};
},
computed: {
@@ -3146,8 +3189,58 @@ export default {
this.loadStickerCount();
this.loadGifCount();
this.loadVisualiserDisplayPrefsFromStorage();
+ this.loadDesktopCloseSettings();
},
methods: {
+ async loadDesktopCloseSettings() {
+ if (!ElectronUtils.isElectron()) {
+ return;
+ }
+ try {
+ const settings = await ElectronUtils.getCloseSettings();
+ if (settings && typeof settings === "object") {
+ this.desktopCloseSettings = {
+ closeBehavior: settings.closeBehavior || "ask",
+ trayEnabled: settings.trayEnabled !== false,
+ };
+ }
+ } catch (e) {
+ console.log(e);
+ }
+ },
+ async onDesktopTrayEnabledChange(value) {
+ this.desktopCloseSettings.trayEnabled = value === true;
+ try {
+ const settings = await ElectronUtils.setCloseSettings({
+ trayEnabled: this.desktopCloseSettings.trayEnabled,
+ });
+ if (settings && typeof settings === "object") {
+ this.desktopCloseSettings = {
+ closeBehavior: settings.closeBehavior || this.desktopCloseSettings.closeBehavior,
+ trayEnabled: settings.trayEnabled !== false,
+ };
+ }
+ } catch (e) {
+ console.log(e);
+ ToastUtils.error(this.$t("common.save_failed"));
+ }
+ },
+ async onDesktopCloseBehaviorChange() {
+ try {
+ const settings = await ElectronUtils.setCloseSettings({
+ closeBehavior: this.desktopCloseSettings.closeBehavior,
+ });
+ if (settings && typeof settings === "object") {
+ this.desktopCloseSettings = {
+ closeBehavior: settings.closeBehavior || this.desktopCloseSettings.closeBehavior,
+ trayEnabled: settings.trayEnabled !== false,
+ };
+ }
+ } catch (e) {
+ console.log(e);
+ ToastUtils.error(this.$t("common.save_failed"));
+ }
+ },
async runSelfTest() {
if (this.selfTestRunning) {
return;

diff --git a/meshchatx/src/frontend/js/ElectronUtils.js b/meshchatx/src/frontend/js/ElectronUtils.js
index 4b32db89..e9d78242 100644
--- a/meshchatx/src/frontend/js/ElectronUtils.js
+++ b/meshchatx/src/frontend/js/ElectronUtils.js
@@ -17,6 +17,20 @@ class ElectronUtils {
}
}
+ static async getCloseSettings() {
+ if (window.electron?.getCloseSettings) {
+ return await window.electron.getCloseSettings();
+ }
+ return null;
+ }
+
+ static async setCloseSettings(partial) {
+ if (window.electron?.setCloseSettings) {
+ return await window.electron.setCloseSettings(partial);
+ }
+ return null;
+ }
+
static async getMemoryUsage() {
if (window.electron) {
return await window.electron.getMemoryUsage();

diff --git a/meshchatx/src/frontend/js/nomadUnknownNodeName.js b/meshchatx/src/frontend/js/nomadUnknownNodeName.js
new file mode 100644
index 00000000..ce5cd938
--- /dev/null
+++ b/meshchatx/src/frontend/js/nomadUnknownNodeName.js
@@ -0,0 +1,68 @@
+// SPDX-License-Identifier: 0BSD
+
+/**
+ * Canonical and localized placeholder names that must not overwrite a stored
+ * favourite display name. Keep in sync with backend UNKNOWN_FAVOURITE_NAMES.
+ */
+export const UNKNOWN_NODE_DISPLAY_NAMES = Object.freeze([
+ "Unknown Node",
+ "Anonymous Node",
+ "Unbekannter Knoten",
+ "Nodo desconocido",
+ "Tuntematon solmu",
+ "Noeud inconnu",
+ "Nodo Sconosciuto",
+ "Onbekende knoop",
+ "Неизвестный узел",
+ "未知节点",
+]);
+
+const UNKNOWN_NODE_NAME_SET = new Set(UNKNOWN_NODE_DISPLAY_NAMES.map((n) => n.toLowerCase()));
+
+/**
+ * @param {unknown} name
+ * @param {string} [localizedUnknown]
+ * @returns {boolean}
+ */
+export function isUnknownNodeDisplayName(name, localizedUnknown = "") {
+ if (typeof name !== "string") {
+ return true;
+ }
+ const trimmed = name.trim();
+ if (!trimmed) {
+ return true;
+ }
+ if (UNKNOWN_NODE_NAME_SET.has(trimmed.toLowerCase())) {
+ return true;
+ }
+ if (typeof localizedUnknown === "string" && localizedUnknown.trim()) {
+ return trimmed.toLowerCase() === localizedUnknown.trim().toLowerCase();
+ }
+ return false;
+}
+
+/**
+ * Prefer a meaningful name for favourite upserts; never send a localized
+ * unknown sentinel that the backend would treat as a real rename.
+ * @param {object|null|undefined} node
+ * @param {object|null|undefined} existingFavourite
+ * @param {string} [localizedUnknown]
+ * @returns {string}
+ */
+export function resolveFavouriteUpsertDisplayName(node, existingFavourite = null, localizedUnknown = "") {
+ const candidate =
+ (typeof node?.custom_display_name === "string" && node.custom_display_name) ||
+ (typeof node?.display_name === "string" && node.display_name) ||
+ "";
+ if (!isUnknownNodeDisplayName(candidate, localizedUnknown)) {
+ return candidate.trim();
+ }
+ const existing =
+ (typeof existingFavourite?.custom_display_name === "string" && existingFavourite.custom_display_name) ||
+ (typeof existingFavourite?.display_name === "string" && existingFavourite.display_name) ||
+ "";
+ if (existing && !isUnknownNodeDisplayName(existing, localizedUnknown)) {
+ return existing.trim();
+ }
+ return "Unknown Node";
+}

diff --git a/meshchatx/src/frontend/js/registries/coreSettingsSectionKeywords.js b/meshchatx/src/frontend/js/registries/coreSettingsSectionKeywords.js
index 135e0fd1..6f1cd342 100644
--- a/meshchatx/src/frontend/js/registries/coreSettingsSectionKeywords.js
+++ b/meshchatx/src/frontend/js/registries/coreSettingsSectionKeywords.js
@@ -121,6 +121,18 @@ export const CORE_SETTINGS_SECTION_KEYWORDS = {
"app.desktop_open_calls_in_separate_window_description",
"app.desktop_hardware_acceleration_enabled",
"app.desktop_hardware_acceleration_enabled_description",
+ "app.desktop_tray_enabled",
+ "app.desktop_tray_enabled_description",
+ "app.desktop_close_behavior",
+ "app.desktop_close_behavior_description",
+ "app.desktop_close_behavior_ask",
+ "app.desktop_close_behavior_quit",
+ "app.desktop_close_behavior_background",
+ "app.desktop_close_behavior_background_no_tray",
+ "tray",
+ "close",
+ "quit",
+ "taskbar",
],
android: [
"Android",

diff --git a/meshchatx/src/frontend/js/relayMessageTimeline.js b/meshchatx/src/frontend/js/relayMessageTimeline.js
index 57cb355a..1a709aa3 100644
--- a/meshchatx/src/frontend/js/relayMessageTimeline.js
+++ b/meshchatx/src/frontend/js/relayMessageTimeline.js
@@ -10,8 +10,53 @@ export function relayMessageKey(msg) {
if (!msg) {
return "";
}
+ if (msg.seq != null && msg.seq !== "") {
+ return `seq-${msg.seq}`;
+ }
const src = msg.src || "";
- return `${msg.kind || "msg"}-${msg.ts || 0}-${src}`;
+ const text = typeof msg.text === "string" ? msg.text : "";
+ return `${msg.kind || "msg"}-${msg.ts || 0}-${src}-${text.length}-${text.slice(0, 24)}`;
+}
+
+/**
+ * True when an incoming relay message is already present in the list.
+ * Prefers seq; falls back to kind/ts/src/text for older payloads without seq.
+ * @param {object[]} messages
+ * @param {object} incoming
+ * @returns {boolean}
+ */
+export function relayMessageAlreadyPresent(messages, incoming) {
+ if (!Array.isArray(messages) || !incoming) {
+ return false;
+ }
+ const incomingSeq = incoming.seq;
+ if (incomingSeq != null && incomingSeq !== "") {
+ return messages.some((m) => m && m.seq === incomingSeq);
+ }
+ const key = relayMessageKey(incoming);
+ if (!key) {
+ return false;
+ }
+ return messages.some((m) => relayMessageKey(m) === key);
+}
+
+/**
+ * Append extras that are not already represented in base (by seq / fallback key).
+ * @param {object[]} base
+ * @param {object[]} extras
+ * @returns {object[]}
+ */
+export function mergeRelayMessages(base, extras) {
+ const out = Array.isArray(base) ? [...base] : [];
+ if (!Array.isArray(extras) || extras.length === 0) {
+ return out;
+ }
+ for (const msg of extras) {
+ if (msg && !relayMessageAlreadyPresent(out, msg)) {
+ out.push(msg);
+ }
+ }
+ return out;
}
/**

diff --git a/meshchatx/src/frontend/locales/de.json b/meshchatx/src/frontend/locales/de.json
index e0400491..9ebc22d3 100644
--- a/meshchatx/src/frontend/locales/de.json
+++ b/meshchatx/src/frontend/locales/de.json
@@ -423,7 +423,15 @@
"flood_threshold": "Messages per minute threshold",
"flood_max_stamp_cost": "Maximum stamp cost during flood",
"flood_cooldown": "Cooldown before lowering cost (seconds)",
- "relay_chat": "Relais-Chat"
+ "relay_chat": "Relais-Chat",
+ "desktop_tray_enabled": "System tray integration",
+ "desktop_tray_enabled_description": "Keep a tray icon so MeshChatX can hide to the background instead of quitting.",
+ "desktop_close_behavior": "When closing the window",
+ "desktop_close_behavior_description": "Choose whether closing the window quits, hides to the tray/taskbar, or asks each time.",
+ "desktop_close_behavior_ask": "Ask every time",
+ "desktop_close_behavior_quit": "Quit application",
+ "desktop_close_behavior_background": "Keep running in background",
+ "desktop_close_behavior_background_no_tray": "Minimize to taskbar"
},
"common": {
"open": "Öffnen",

diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json
index b1543711..78a821d9 100644
--- a/meshchatx/src/frontend/locales/en.json
+++ b/meshchatx/src/frontend/locales/en.json
@@ -319,6 +319,14 @@
"desktop_open_calls_in_separate_window_description": "When a call is active, it will be shown in a separate pop-out window rather than inside the app.",
"desktop_hardware_acceleration_enabled": "Hardware Acceleration",
"desktop_hardware_acceleration_enabled_description": "Disable this if you experience flickering, black screens, or other graphical issues.",
+ "desktop_tray_enabled": "System tray integration",
+ "desktop_tray_enabled_description": "Keep a tray icon so MeshChatX can hide to the background instead of quitting.",
+ "desktop_close_behavior": "When closing the window",
+ "desktop_close_behavior_description": "Choose whether closing the window quits, hides to the tray/taskbar, or asks each time.",
+ "desktop_close_behavior_ask": "Ask every time",
+ "desktop_close_behavior_quit": "Quit application",
+ "desktop_close_behavior_background": "Keep running in background",
+ "desktop_close_behavior_background_no_tray": "Minimize to taskbar",
"translator": "Translator",
"translator_enabled": "Enable Translator",
"translator_description": "Enable translation features in conversations.",

diff --git a/meshchatx/src/frontend/locales/es.json b/meshchatx/src/frontend/locales/es.json
index ab30f6e9..2a3d3648 100644
--- a/meshchatx/src/frontend/locales/es.json
+++ b/meshchatx/src/frontend/locales/es.json
@@ -423,7 +423,15 @@
"flood_threshold": "Messages per minute threshold",
"flood_max_stamp_cost": "Maximum stamp cost during flood",
"flood_cooldown": "Cooldown before lowering cost (seconds)",
- "relay_chat": "Chat de retransmision"
+ "relay_chat": "Chat de retransmision",
+ "desktop_tray_enabled": "System tray integration",
+ "desktop_tray_enabled_description": "Keep a tray icon so MeshChatX can hide to the background instead of quitting.",
+ "desktop_close_behavior": "When closing the window",
+ "desktop_close_behavior_description": "Choose whether closing the window quits, hides to the tray/taskbar, or asks each time.",
+ "desktop_close_behavior_ask": "Ask every time",
+ "desktop_close_behavior_quit": "Quit application",
+ "desktop_close_behavior_background": "Keep running in background",
+ "desktop_close_behavior_background_no_tray": "Minimize to taskbar"
},
"common": {
"open": "Abierto",

diff --git a/meshchatx/src/frontend/locales/fi.json b/meshchatx/src/frontend/locales/fi.json
index 27b3ce19..d040b146 100644
--- a/meshchatx/src/frontend/locales/fi.json
+++ b/meshchatx/src/frontend/locales/fi.json
@@ -423,7 +423,15 @@
"telemetry_trust_failed": "Käyttötietojen luottamuksen päivitys epäonnistui.",
"telemetry_trust_revoke": "Poista käyttötietojen luottamus",
"telemetry_trust_grant": "Luota käyttötietojen välitystä varten",
- "location_manage_desc": "Hallinnoi sijainnin jakamisen asetuksia."
+ "location_manage_desc": "Hallinnoi sijainnin jakamisen asetuksia.",
+ "desktop_tray_enabled": "System tray integration",
+ "desktop_tray_enabled_description": "Keep a tray icon so MeshChatX can hide to the background instead of quitting.",
+ "desktop_close_behavior": "When closing the window",
+ "desktop_close_behavior_description": "Choose whether closing the window quits, hides to the tray/taskbar, or asks each time.",
+ "desktop_close_behavior_ask": "Ask every time",
+ "desktop_close_behavior_quit": "Quit application",
+ "desktop_close_behavior_background": "Keep running in background",
+ "desktop_close_behavior_background_no_tray": "Minimize to taskbar"
},
"common": {
"open": "Avaa",

diff --git a/meshchatx/src/frontend/locales/fr.json b/meshchatx/src/frontend/locales/fr.json
index 499b41c7..37bf5652 100644
--- a/meshchatx/src/frontend/locales/fr.json
+++ b/meshchatx/src/frontend/locales/fr.json
@@ -423,7 +423,15 @@
"flood_threshold": "Messages per minute threshold",
"flood_max_stamp_cost": "Maximum stamp cost during flood",
"flood_cooldown": "Cooldown before lowering cost (seconds)",
- "relay_chat": "Chat relais"
+ "relay_chat": "Chat relais",
+ "desktop_tray_enabled": "System tray integration",
+ "desktop_tray_enabled_description": "Keep a tray icon so MeshChatX can hide to the background instead of quitting.",
+ "desktop_close_behavior": "When closing the window",
+ "desktop_close_behavior_description": "Choose whether closing the window quits, hides to the tray/taskbar, or asks each time.",
+ "desktop_close_behavior_ask": "Ask every time",
+ "desktop_close_behavior_quit": "Quit application",
+ "desktop_close_behavior_background": "Keep running in background",
+ "desktop_close_behavior_background_no_tray": "Minimize to taskbar"
},
"common": {
"open": "Ouvrir",

diff --git a/meshchatx/src/frontend/locales/it.json b/meshchatx/src/frontend/locales/it.json
index e0e77424..3fb39e62 100644
--- a/meshchatx/src/frontend/locales/it.json
+++ b/meshchatx/src/frontend/locales/it.json
@@ -423,7 +423,15 @@
"flood_threshold": "Messages per minute threshold",
"flood_max_stamp_cost": "Maximum stamp cost during flood",
"flood_cooldown": "Cooldown before lowering cost (seconds)",
- "relay_chat": "Chat relay"
+ "relay_chat": "Chat relay",
+ "desktop_tray_enabled": "System tray integration",
+ "desktop_tray_enabled_description": "Keep a tray icon so MeshChatX can hide to the background instead of quitting.",
+ "desktop_close_behavior": "When closing the window",
+ "desktop_close_behavior_description": "Choose whether closing the window quits, hides to the tray/taskbar, or asks each time.",
+ "desktop_close_behavior_ask": "Ask every time",
+ "desktop_close_behavior_quit": "Quit application",
+ "desktop_close_behavior_background": "Keep running in background",
+ "desktop_close_behavior_background_no_tray": "Minimize to taskbar"
},
"common": {
"open": "Apri",

diff --git a/meshchatx/src/frontend/locales/nl.json b/meshchatx/src/frontend/locales/nl.json
index 44a69ab4..aa773deb 100644
--- a/meshchatx/src/frontend/locales/nl.json
+++ b/meshchatx/src/frontend/locales/nl.json
@@ -423,7 +423,15 @@
"flood_threshold": "Messages per minute threshold",
"flood_max_stamp_cost": "Maximum stamp cost during flood",
"flood_cooldown": "Cooldown before lowering cost (seconds)",
- "relay_chat": "Relaychat"
+ "relay_chat": "Relaychat",
+ "desktop_tray_enabled": "System tray integration",
+ "desktop_tray_enabled_description": "Keep a tray icon so MeshChatX can hide to the background instead of quitting.",
+ "desktop_close_behavior": "When closing the window",
+ "desktop_close_behavior_description": "Choose whether closing the window quits, hides to the tray/taskbar, or asks each time.",
+ "desktop_close_behavior_ask": "Ask every time",
+ "desktop_close_behavior_quit": "Quit application",
+ "desktop_close_behavior_background": "Keep running in background",
+ "desktop_close_behavior_background_no_tray": "Minimize to taskbar"
},
"common": {
"open": "Open",

diff --git a/meshchatx/src/frontend/locales/ru.json b/meshchatx/src/frontend/locales/ru.json
index fb6e7248..709e054b 100644
--- a/meshchatx/src/frontend/locales/ru.json
+++ b/meshchatx/src/frontend/locales/ru.json
@@ -423,7 +423,15 @@
"flood_threshold": "Messages per minute threshold",
"flood_max_stamp_cost": "Maximum stamp cost during flood",
"flood_cooldown": "Cooldown before lowering cost (seconds)",
- "relay_chat": "Релейный чат"
+ "relay_chat": "Релейный чат",
+ "desktop_tray_enabled": "System tray integration",
+ "desktop_tray_enabled_description": "Keep a tray icon so MeshChatX can hide to the background instead of quitting.",
+ "desktop_close_behavior": "When closing the window",
+ "desktop_close_behavior_description": "Choose whether closing the window quits, hides to the tray/taskbar, or asks each time.",
+ "desktop_close_behavior_ask": "Ask every time",
+ "desktop_close_behavior_quit": "Quit application",
+ "desktop_close_behavior_background": "Keep running in background",
+ "desktop_close_behavior_background_no_tray": "Minimize to taskbar"
},
"common": {
"open": "Открыть",

diff --git a/meshchatx/src/frontend/locales/zh.json b/meshchatx/src/frontend/locales/zh.json
index 69e330ba..00f517b3 100644
--- a/meshchatx/src/frontend/locales/zh.json
+++ b/meshchatx/src/frontend/locales/zh.json
@@ -423,7 +423,15 @@
"flood_threshold": "Messages per minute threshold",
"flood_max_stamp_cost": "Maximum stamp cost during flood",
"flood_cooldown": "Cooldown before lowering cost (seconds)",
- "relay_chat": "中继聊天"
+ "relay_chat": "中继聊天",
+ "desktop_tray_enabled": "System tray integration",
+ "desktop_tray_enabled_description": "Keep a tray icon so MeshChatX can hide to the background instead of quitting.",
+ "desktop_close_behavior": "When closing the window",
+ "desktop_close_behavior_description": "Choose whether closing the window quits, hides to the tray/taskbar, or asks each time.",
+ "desktop_close_behavior_ask": "Ask every time",
+ "desktop_close_behavior_quit": "Quit application",
+ "desktop_close_behavior_background": "Keep running in background",
+ "desktop_close_behavior_background_no_tray": "Minimize to taskbar"
},
"common": {
"open": "打开",

diff --git a/tests/backend/test_announce_dao_extended.py b/tests/backend/test_announce_dao_extended.py
index ec069889..fc947a08 100644
--- a/tests/backend/test_announce_dao_extended.py
+++ b/tests/backend/test_announce_dao_extended.py
@@ -154,3 +154,26 @@ def test_get_favourite_by_destination_hash(announce_dao):
announce_dao.upsert_favourite("dh1", "Renamed", row["aspect"])
row2 = announce_dao.get_favourite_by_destination_hash("dh1")
assert row2["display_name"] == "Renamed"
+
+
+def test_upsert_favourite_preserves_name_for_unknown_sentinel(announce_dao):
+ announce_dao.upsert_favourite("dh1", "Kept Name", "nomadnetwork.node")
+ announce_dao.upsert_favourite("dh1", "Unknown Node", "nomadnetwork.node")
+ row = announce_dao.get_favourite_by_destination_hash("dh1")
+ assert row["display_name"] == "Kept Name"
+ announce_dao.upsert_favourite("dh1", "New Real Name", "nomadnetwork.node")
+ row2 = announce_dao.get_favourite_by_destination_hash("dh1")
+ assert row2["display_name"] == "New Real Name"
+
+
+def test_upsert_favourite_preserves_name_for_localized_unknown(announce_dao):
+ announce_dao.upsert_favourite("dh1", "Kept Name", "nomadnetwork.node")
+ for sentinel in (
+ "Unbekannter Knoten",
+ "未知节点",
+ "Anonymous Node",
+ "Неизвестный узел",
+ ):
+ announce_dao.upsert_favourite("dh1", sentinel, "nomadnetwork.node")
+ row = announce_dao.get_favourite_by_destination_hash("dh1")
+ assert row["display_name"] == "Kept Name", sentinel

diff --git a/tests/backend/test_favourite_display_names.py b/tests/backend/test_favourite_display_names.py
new file mode 100644
index 00000000..8c32971a
--- /dev/null
+++ b/tests/backend/test_favourite_display_names.py
@@ -0,0 +1,21 @@
+from meshchatx.src.backend.favourite_display_names import (
+ UNKNOWN_FAVOURITE_NAMES,
+ is_unknown_favourite_display_name,
+)
+
+
+def test_unknown_favourite_names_include_localized_placeholders():
+ assert "" in UNKNOWN_FAVOURITE_NAMES
+ assert "Unknown Node" in UNKNOWN_FAVOURITE_NAMES
+ assert "Anonymous Node" in UNKNOWN_FAVOURITE_NAMES
+ assert "Unbekannter Knoten" in UNKNOWN_FAVOURITE_NAMES
+ assert "未知节点" in UNKNOWN_FAVOURITE_NAMES
+
+
+def test_is_unknown_favourite_display_name():
+ assert is_unknown_favourite_display_name(None) is True
+ assert is_unknown_favourite_display_name("") is True
+ assert is_unknown_favourite_display_name(" ") is True
+ assert is_unknown_favourite_display_name("Unknown Node") is True
+ assert is_unknown_favourite_display_name("Неизвестный узел") is True
+ assert is_unknown_favourite_display_name("Real Node") is False

diff --git a/tests/electron/closeBehavior.test.js b/tests/electron/closeBehavior.test.js
new file mode 100644
index 00000000..f2c0ad15
--- /dev/null
+++ b/tests/electron/closeBehavior.test.js
@@ -0,0 +1,74 @@
+import { createRequire } from "module";
+import path from "path";
+import fs from "fs";
+import os from "os";
+import { afterEach, describe, expect, it } from "vitest";
+
+const require = createRequire(import.meta.url);
+const {
+ defaultCloseSettings,
+ normalizeCloseSettings,
+ resolveCloseAction,
+ loadCloseSettings,
+ saveCloseSettings,
+ rememberedCloseSettings,
+ createCloseRequestGuard,
+} = require("../../electron/closeBehavior.js");
+
+describe("electron/closeBehavior", () => {
+ const tempDirs = [];
+
+ afterEach(() => {
+ for (const dir of tempDirs.splice(0)) {
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
+ it("defaults to ask with tray enabled", () => {
+ expect(defaultCloseSettings()).toEqual({
+ closeBehavior: "ask",
+ trayEnabled: true,
+ });
+ });
+
+ it("normalizes invalid values", () => {
+ expect(
+ normalizeCloseSettings({
+ closeBehavior: "nope",
+ trayEnabled: "yes",
+ })
+ ).toEqual(defaultCloseSettings());
+ });
+
+ it("resolveCloseAction maps background to minimize when tray is off", () => {
+ expect(resolveCloseAction({ closeBehavior: "ask", trayEnabled: true })).toBe("ask");
+ expect(resolveCloseAction({ closeBehavior: "quit", trayEnabled: true })).toBe("quit");
+ expect(resolveCloseAction({ closeBehavior: "background", trayEnabled: true })).toBe("background");
+ expect(resolveCloseAction({ closeBehavior: "background", trayEnabled: false })).toBe("minimize");
+ });
+
+ it("persists and reloads settings from storage dir", () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "meshchatx-close-"));
+ tempDirs.push(dir);
+ const saved = saveCloseSettings(dir, { closeBehavior: "quit", trayEnabled: false });
+ expect(saved).toEqual({ closeBehavior: "quit", trayEnabled: false });
+ expect(loadCloseSettings(dir)).toEqual({ closeBehavior: "quit", trayEnabled: false });
+ expect(fs.existsSync(path.join(dir, "desktop-close-settings.json"))).toBe(true);
+ });
+
+ it("rememberedCloseSettings maps minimize/background to background", () => {
+ expect(rememberedCloseSettings("quit", true)).toEqual({ closeBehavior: "quit" });
+ expect(rememberedCloseSettings("background", true)).toEqual({ closeBehavior: "background" });
+ expect(rememberedCloseSettings("minimize", true)).toEqual({ closeBehavior: "background" });
+ expect(rememberedCloseSettings("quit", false)).toBeNull();
+ });
+
+ it("createCloseRequestGuard blocks re-entrant close handling", () => {
+ const guard = createCloseRequestGuard();
+ expect(guard.tryEnter()).toBe(true);
+ expect(guard.tryEnter()).toBe(false);
+ guard.leave();
+ expect(guard.tryEnter()).toBe(true);
+ guard.leave();
+ });
+});

diff --git a/tests/electron/preload.test.js b/tests/electron/preload.test.js
index dac2a827..b5ddf5bb 100644
--- a/tests/electron/preload.test.js
+++ b/tests/electron/preload.test.js
@@ -89,6 +89,26 @@ describe("electron/preload", () => {
expect(cb).toHaveBeenCalledWith({ code: 255 });
});
+ it("exposes close settings IPC helpers", async () => {
+ const exposeInMainWorld = vi.fn();
+ const invoke = vi.fn();
+ loadPreloadWithElectronMock({
+ contextBridge: { exposeInMainWorld },
+ ipcRenderer: { invoke, on: vi.fn() },
+ });
+ const api = exposeInMainWorld.mock.calls[0][1];
+ invoke.mockResolvedValueOnce({ closeBehavior: "ask", trayEnabled: true });
+ await expect(api.getCloseSettings()).resolves.toEqual({ closeBehavior: "ask", trayEnabled: true });
+ expect(invoke).toHaveBeenCalledWith("get-close-settings");
+
+ invoke.mockResolvedValueOnce({ closeBehavior: "quit", trayEnabled: false });
+ await expect(api.setCloseSettings({ closeBehavior: "quit" })).resolves.toEqual({
+ closeBehavior: "quit",
+ trayEnabled: false,
+ });
+ expect(invoke).toHaveBeenCalledWith("set-close-settings", { closeBehavior: "quit" });
+ });
+
it("subscribes to log channel on load", () => {
const exposeInMainWorld = vi.fn();
const on = vi.fn();

diff --git a/tests/frontend/NomadNetworkPage.test.js b/tests/frontend/NomadNetworkPage.test.js
index 3d61f4d4..f4c7c738 100644
--- a/tests/frontend/NomadNetworkPage.test.js
+++ b/tests/frontend/NomadNetworkPage.test.js
@@ -670,6 +670,82 @@ describe("NomadNetworkPage.vue", () => {
expect(ToastUtils.success).toHaveBeenCalledWith("nomadnet.favourite_added");
});
+ it("resolveNodeForHash prefers favourite name over Unknown Node stub", () => {
+ const wrapper = mountNomadNetworkPage();
+ const hash = "a".repeat(32);
+ wrapper.vm.nodes = {};
+ wrapper.vm.favourites = [{ destination_hash: hash, display_name: "Saved Favourite" }];
+ const resolved = wrapper.vm.resolveNodeForHash(hash);
+ expect(resolved.display_name).toBe("Saved Favourite");
+ expect(resolved.destination_hash).toBe(hash);
+ });
+
+ it("addFavourite does not overwrite existing favourite with Unknown Node", async () => {
+ axiosMock.post.mockResolvedValueOnce({ data: {} });
+ axiosMock.get.mockResolvedValueOnce({ data: { favourites: [] } });
+ const wrapper = mountNomadNetworkPage();
+ const hash = "a".repeat(32);
+ wrapper.vm.favourites = [{ destination_hash: hash, display_name: "Kept Name" }];
+ await wrapper.vm.addFavourite({
+ destination_hash: hash,
+ display_name: "Unknown Node",
+ });
+ expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/favourites/add", {
+ destination_hash: hash,
+ display_name: "Kept Name",
+ aspect: "nomadnetwork.node",
+ });
+ });
+
+ it("addFavourite canonicalizes localized unknown names for new favourites", async () => {
+ axiosMock.post.mockResolvedValueOnce({ data: {} });
+ axiosMock.get.mockResolvedValueOnce({ data: { favourites: [] } });
+ const wrapper = mountNomadNetworkPage();
+ const hash = "b".repeat(32);
+ wrapper.vm.favourites = [];
+ await wrapper.vm.addFavourite({
+ destination_hash: hash,
+ display_name: "Unbekannter Knoten",
+ });
+ expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/favourites/add", {
+ destination_hash: hash,
+ display_name: "Unknown Node",
+ aspect: "nomadnetwork.node",
+ });
+ });
+
+ it("onNodeClick resolves favourite names through announce cache", async () => {
+ const wrapper = mountNomadNetworkPage();
+ const hash = "c".repeat(32);
+ wrapper.vm.nodes = {
+ [hash]: {
+ destination_hash: hash,
+ display_name: "From Announce",
+ aspect: "nomadnetwork.node",
+ },
+ };
+ wrapper.vm.favourites = [{ destination_hash: hash, display_name: "Unknown Node" }];
+ const loadSpy = vi.spyOn(wrapper.vm, "loadNodePage").mockResolvedValue();
+ wrapper.vm.onNodeClick({ destination_hash: hash, display_name: "Unknown Node" });
+ expect(wrapper.vm.selectedNode.display_name).toBe("From Announce");
+ expect(loadSpy).toHaveBeenCalledWith(hash, wrapper.vm.defaultNodePagePath);
+ loadSpy.mockRestore();
+ });
+
+ it("onBulkAddFavouritesFromAnnounces uses canonical unknown sentinel", async () => {
+ axiosMock.post.mockResolvedValue({ data: {} });
+ axiosMock.get.mockResolvedValue({ data: { favourites: [] } });
+ const wrapper = mountNomadNetworkPage();
+ wrapper.vm.favourites = [];
+ const hash = "d".repeat(32);
+ await wrapper.vm.onBulkAddFavouritesFromAnnounces([{ destination_hash: hash, display_name: "未知节点" }]);
+ expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/favourites/add", {
+ destination_hash: hash,
+ display_name: "Unknown Node",
+ aspect: "nomadnetwork.node",
+ });
+ });
+
it("toggleFavouriteFromContext reports API failures", async () => {
axiosMock.post.mockRejectedValueOnce(new Error("network"));
const wrapper = mountNomadNetworkPage();

diff --git a/tests/frontend/NomadNetworkSidebar.test.js b/tests/frontend/NomadNetworkSidebar.test.js
index a28777b1..6d7c3a12 100644
--- a/tests/frontend/NomadNetworkSidebar.test.js
+++ b/tests/frontend/NomadNetworkSidebar.test.js
@@ -298,4 +298,54 @@ describe("NomadNetworkSidebar.vue", () => {
expect(wrapper.text()).toContain("nomadnet.no_announces_yet");
expect(wrapper.text()).not.toContain("nomadnet.no_search_results_peers");
});
+
+ it("favouriteDisplayName prefers announce cache over unknown favourite label", async () => {
+ const favHash = defaultFavourite.destination_hash;
+ const wrapper = mountSidebar({
+ favourites: [{ destination_hash: favHash, display_name: "Unknown Node" }],
+ nodes: {
+ [favHash]: {
+ destination_hash: favHash,
+ display_name: "Live Announce Name",
+ },
+ [defaultNode.destination_hash]: defaultNode,
+ },
+ });
+ await wrapper.vm.$nextTick();
+ expect(wrapper.vm.favouriteDisplayName(wrapper.vm.favourites[0])).toBe("Live Announce Name");
+ expect(wrapper.text()).toContain("Live Announce Name");
+ });
+
+ it("does not wipe persisted favourite section layout when favourites are still empty", async () => {
+ const favHash = defaultFavourite.destination_hash;
+ const layout = {
+ sections: [
+ { id: "default", name: "Favourites", collapsed: false },
+ { id: "custom", name: "Custom", collapsed: false },
+ ],
+ sectionOrder: ["default", "custom"],
+ favouritesBySection: {
+ default: [],
+ custom: [favHash],
+ },
+ };
+ localStorage.getItem.mockImplementation((key) => {
+ if (key === "meshchat.nomadnet.favourites.layout") {
+ return JSON.stringify(layout);
+ }
+ return null;
+ });
+
+ const wrapper = mountSidebar({ favourites: [] });
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.favouritesBySection.custom).toEqual([favHash]);
+ expect(localStorage.setItem).not.toHaveBeenCalled();
+
+ await wrapper.setProps({ favourites: [defaultFavourite] });
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.favouritesBySection.custom).toContain(favHash);
+ expect(wrapper.vm.favouritesBySection.default || []).not.toContain(favHash);
+ });
});

diff --git a/tests/frontend/RelayChatPage.test.js b/tests/frontend/RelayChatPage.test.js
index 15f9124f..fdbff628 100644
--- a/tests/frontend/RelayChatPage.test.js
+++ b/tests/frontend/RelayChatPage.test.js
@@ -189,6 +189,112 @@ describe("RelayChatPage.vue", () => {
expect(wrapper.text()).toContain("hello");
});
+ it("keeps websocket messages that arrive while selectRoom is loading", async () => {
+ let resolveMessages;
+ axiosMock.get.mockImplementation((url) => {
+ if (url === "/api/v1/rrc/hubs") {
+ return Promise.resolve({ data: { hubs: [makeHub()] } });
+ }
+ if (url === "/api/v1/rrc/servers") {
+ return Promise.resolve({ data: { hubs: [makeHostedHub()] } });
+ }
+ if (url === "/api/v1/announces") {
+ return Promise.resolve({ data: { announces: [makeAnnounce()] } });
+ }
+ if (url.includes("/rooms/") && url.endsWith("/messages")) {
+ return new Promise((resolve) => {
+ resolveMessages = resolve;
+ });
+ }
+ return Promise.resolve({ data: {} });
+ });
+
+ const wrapper = mountPage();
+ await vi.waitFor(() => expect(wrapper.vm.hubs.length).toBe(1));
+
+ const selectPromise = wrapper.vm.selectRoom(HUB_HASH, "lobby");
+ await vi.waitFor(() => expect(typeof resolveMessages).toBe("function"));
+
+ wrapper.vm.onWebsocketMessage({
+ data: JSON.stringify({
+ type: "rrc.message",
+ hub_hash: HUB_HASH,
+ room: "lobby",
+ message: {
+ kind: "msg",
+ room: "lobby",
+ src: "live",
+ nick: "live",
+ text: "during-load",
+ ts: 99,
+ seq: 99,
+ mention: false,
+ },
+ }),
+ });
+ expect(wrapper.vm.messages.some((m) => m.text === "during-load")).toBe(true);
+
+ resolveMessages({
+ data: {
+ messages: [
+ {
+ kind: "msg",
+ room: "lobby",
+ src: "aabb",
+ nick: "carol",
+ text: "hello",
+ ts: 1,
+ seq: 1,
+ mention: false,
+ },
+ ],
+ members: [{ hash: "aabb", name: "carol" }],
+ has_more: false,
+ },
+ });
+ await selectPromise;
+
+ expect(wrapper.vm.messages.map((m) => m.text)).toEqual(["hello", "during-load"]);
+ });
+
+ it("dedupes websocket messages that already exist by seq", async () => {
+ const wrapper = mountPage();
+ await vi.waitFor(() => expect(wrapper.vm.hubs.length).toBe(1));
+ await wrapper.vm.selectRoom(HUB_HASH, "lobby");
+ wrapper.vm.messages = [
+ {
+ kind: "msg",
+ room: "lobby",
+ src: "aabb",
+ nick: "carol",
+ text: "hello",
+ ts: 1,
+ seq: 5,
+ mention: false,
+ },
+ ];
+
+ wrapper.vm.onWebsocketMessage({
+ data: JSON.stringify({
+ type: "rrc.message",
+ hub_hash: HUB_HASH,
+ room: "lobby",
+ message: {
+ kind: "msg",
+ room: "lobby",
+ src: "aabb",
+ nick: "carol",
+ text: "hello",
+ ts: 1,
+ seq: 5,
+ mention: false,
+ },
+ }),
+ });
+
+ expect(wrapper.vm.messages).toHaveLength(1);
+ });
+
it("sends a message via the API", async () => {
const wrapper = mountPage();
await vi.waitFor(() => expect(wrapper.vm.hubs.length).toBe(1));

diff --git a/tests/frontend/nomadUnknownNodeName.test.js b/tests/frontend/nomadUnknownNodeName.test.js
new file mode 100644
index 00000000..dfcd27fa
--- /dev/null
+++ b/tests/frontend/nomadUnknownNodeName.test.js
@@ -0,0 +1,53 @@
+import { describe, expect, it } from "vitest";
+import {
+ isUnknownNodeDisplayName,
+ resolveFavouriteUpsertDisplayName,
+ UNKNOWN_NODE_DISPLAY_NAMES,
+} from "@/js/nomadUnknownNodeName.js";
+
+describe("nomadUnknownNodeName", () => {
+ it("treats empty and known placeholders as unknown", () => {
+ expect(isUnknownNodeDisplayName("")).toBe(true);
+ expect(isUnknownNodeDisplayName(" ")).toBe(true);
+ expect(isUnknownNodeDisplayName(null)).toBe(true);
+ expect(isUnknownNodeDisplayName("Unknown Node")).toBe(true);
+ expect(isUnknownNodeDisplayName("Anonymous Node")).toBe(true);
+ expect(isUnknownNodeDisplayName("Unbekannter Knoten")).toBe(true);
+ expect(isUnknownNodeDisplayName("未知节点")).toBe(true);
+ expect(isUnknownNodeDisplayName("Real Node")).toBe(false);
+ });
+
+ it("honors a localized unknown string from i18n", () => {
+ expect(isUnknownNodeDisplayName("Custom Unknown", "Custom Unknown")).toBe(true);
+ expect(isUnknownNodeDisplayName("Custom Unknown", "Other")).toBe(false);
+ });
+
+ it("resolveFavouriteUpsertDisplayName keeps existing real names", () => {
+ expect(
+ resolveFavouriteUpsertDisplayName(
+ { display_name: "Unknown Node" },
+ { display_name: "Kept" },
+ "Unknown Node"
+ )
+ ).toBe("Kept");
+ expect(
+ resolveFavouriteUpsertDisplayName(
+ { display_name: "Unbekannter Knoten" },
+ { display_name: "Kept" },
+ "Unbekannter Knoten"
+ )
+ ).toBe("Kept");
+ });
+
+ it("resolveFavouriteUpsertDisplayName canonicalizes unknown for new favourites", () => {
+ expect(resolveFavouriteUpsertDisplayName({ display_name: "未知节点" }, null, "未知节点")).toBe("Unknown Node");
+ expect(resolveFavouriteUpsertDisplayName({ display_name: "Fresh Name" }, null, "Unknown Node")).toBe(
+ "Fresh Name"
+ );
+ });
+
+ it("exports the shared sentinel list", () => {
+ expect(UNKNOWN_NODE_DISPLAY_NAMES).toContain("Unknown Node");
+ expect(UNKNOWN_NODE_DISPLAY_NAMES).toContain("Неизвестный узел");
+ });
+});

diff --git a/tests/frontend/relayMessageSearch.test.js b/tests/frontend/relayMessageSearch.test.js
index 84853ce7..8b3bb753 100644
--- a/tests/frontend/relayMessageSearch.test.js
+++ b/tests/frontend/relayMessageSearch.test.js
@@ -5,7 +5,12 @@ import {
parseRelaySearchQuery,
parseDateSearchToken,
} from "@/js/relayMessageSearch.js";
-import { buildRelayMessageTimeline, relayMessageKey } from "@/js/relayMessageTimeline.js";
+import {
+ buildRelayMessageTimeline,
+ mergeRelayMessages,
+ relayMessageAlreadyPresent,
+ relayMessageKey,
+} from "@/js/relayMessageTimeline.js";
const displayName = (msg) => msg.nick || "anon";
@@ -80,4 +85,34 @@ describe("relayMessageTimeline", () => {
const msg = { kind: "msg", ts: 1, src: "ab", text: "x" };
expect(relayMessageKey(msg)).toBe(relayMessageKey(msg));
});
+
+ it("relayMessageKey prefers seq when present", () => {
+ const msg = { kind: "msg", ts: 1, src: "ab", text: "x", seq: 42 };
+ expect(relayMessageKey(msg)).toBe("seq-42");
+ });
+
+ it("relayMessageAlreadyPresent matches by seq", () => {
+ const messages = [{ kind: "msg", ts: 1, src: "ab", text: "x", seq: 7 }];
+ expect(relayMessageAlreadyPresent(messages, { kind: "msg", ts: 99, src: "cd", text: "y", seq: 7 })).toBe(true);
+ expect(relayMessageAlreadyPresent(messages, { kind: "msg", ts: 1, src: "ab", text: "x", seq: 8 })).toBe(false);
+ });
+
+ it("relayMessageAlreadyPresent falls back when seq is missing", () => {
+ const messages = [{ kind: "msg", ts: 1, src: "ab", text: "hello" }];
+ expect(relayMessageAlreadyPresent(messages, { kind: "msg", ts: 1, src: "ab", text: "hello" })).toBe(true);
+ expect(relayMessageAlreadyPresent(messages, { kind: "msg", ts: 1, src: "ab", text: "other" })).toBe(false);
+ });
+
+ it("mergeRelayMessages keeps websocket arrivals not in the loaded page", () => {
+ const loaded = [
+ { kind: "msg", ts: 1, src: "ab", text: "a", seq: 1 },
+ { kind: "msg", ts: 2, src: "ab", text: "b", seq: 2 },
+ ];
+ const live = [
+ { kind: "msg", ts: 2, src: "ab", text: "b", seq: 2 },
+ { kind: "msg", ts: 3, src: "cd", text: "c", seq: 3 },
+ ];
+ const merged = mergeRelayMessages(loaded, live);
+ expect(merged.map((m) => m.seq)).toEqual([1, 2, 3]);
+ });
});


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────